﻿using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.IO;
using System.Xml;
using System.Text;

namespace NUnitResults
{
    /// <summary>
    /// Class which reads a stream of XML results and returns them
    /// as a list of ITestResults.
    /// </summary>
    public class ResultFile
    {
        #region Classes

        /// <summary>
        /// Represents a non-fatal issue which occurred during the read.
        /// </summary>
        public class Warning
        {
            /// <summary>
            /// Gets the message.
            /// </summary>
            public String  Message { get; private set; }

            /// <summary>
            /// Gets the source of the message.
            /// </summary>
            public String Source { get; private set; }

            /// <summary>
            /// Creates a new warning.
            /// </summary>
            /// <param name="message">The message.</param>
            /// <param name="source">The source of the message.</param>
            public Warning(String message, String source)
            {
                if (message == null)
                {
                    throw new ArgumentNullException("message");
                }
                if (source == null)
                {
                    throw new ArgumentNullException("source");
                }

                Message = message;
                Source = source;
            }
        }

        /// <summary>
        /// Represents a fatal issue which occurred during the read.
        /// </summary>
        public class Error : Warning
        {
            /// <summary>
            /// The stack trace of the exception that occurred.
            /// </summary>
            public String StackTrace { get; private set; }

            /// <summary>
            /// Creates a new error.
            /// </summary>
            /// <param name="message">The exception's message.</param>
            /// <param name="source">The source of the problem.</param>
            /// <param name="stackTrace">The exception's stack trace.</param>
            public Error(String message, String source, String stackTrace)
                :base(message, source)
            {
                if (stackTrace == null)
                {
                    throw new ArgumentNullException("stackTrace");
                }

                StackTrace = stackTrace;
            }
        }

        #endregion

        #region Fields / Properties

        /// <summary>
        /// Provides access to the XML results stream.
        /// </summary>
        private readonly IStreamProvider streamProvider;

        /// <summary>
        /// Records warnings that occurred in the last read.
        /// </summary>
        private readonly IList<Warning> warnings;

        /// <summary>
        /// Gets a list of warnings which occured during the last read.
        /// </summary>
        public ReadOnlyCollection<Warning> Warnings { get { return new ReadOnlyCollection<Warning>(warnings); } }

        #endregion

        #region Constructor

        /// <summary>
        /// Creates a new ResultsFile.
        /// </summary>
        /// <param name="xmlResultsStream">A class which provides access to the stream of results.</param>
        public ResultFile(IStreamProvider xmlResultsStream)
        {
            if (xmlResultsStream == null)
            {
                throw new ArgumentNullException("xmlResultsStream");
            }

            streamProvider = xmlResultsStream;
            warnings = new List<Warning>();
        }

        #endregion

        #region Public Methods

        /// <summary>
        /// Reads the results from the stream and returns a list of ITestResults.
        /// Any problems are recorded in this instance's Warnings property.
        /// </summary>
        /// <returns>A list of ITestResults if the read succeeded, otherwise an empty list of
        /// ITestResults.</returns>
        public IList<ITestResult> ReadTestResults()
        {
            warnings.Clear();

            try
            {
                using (var stream = streamProvider.GetStream())
                using (var reader = new StreamReader(stream))
                {
                    var xmlReader = XmlReader.Create(reader);
                    return ReadResults(xmlReader, null);
                }
            }
            catch (Exception ex)
            {
                warnings.Add(new Error(ex.Message, streamProvider.Name, ex.StackTrace));
                return new List<ITestResult>(0);
            }
        }

        #endregion

        #region Main Read Methods

        /// <summary>
        /// Takes the supplied XmlReader, and uses it to read the test results.
        /// </summary>
        /// <param name="reader"></param>
        /// <returns></returns>
        private IList<ITestResult> ReadResults(XmlReader reader, String parentNamespace)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            var tests = new List<ITestResult>();

            while (true)
            {
                ReadToNextNodeType(reader, XmlNodeType.Element);
                
                if (reader.EOF)
                {
                    return tests;
                }

                switch (reader.Name)
                {
                    case "test-suite":
                        tests.Add(ReadTestSuite(reader, parentNamespace));
                        continue;
                    case "test-case":
                        tests.Add(ReadTestCase(reader, parentNamespace));
                        continue;
                    case "test-results":
                    case "environment":
                    case "culture-info":
                    case "results":
                    case "categories":
                    case "category":
                    case "reason":
                    case "message":
                    case "stack-trace":
                        continue;
                    default:
                        warnings.Add(new Warning(String.Format("Unrecognised element: '{0}'", reader.Name), streamProvider.Name));
                        continue;
                }
            }
        }

        /// <summary>
        /// Reads the supplied reader until we get to a node of the specified type.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <param name="nodeType">The type of node to stop at.</param>
        private static void ReadToNextNodeType(XmlReader reader, XmlNodeType nodeType)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            reader.Read();
            while (reader.NodeType != nodeType && reader.Read()) ;
        }

        /// <summary>
        /// Reads the supplied reader until we get to a node of the specified type and with
        /// the specified name.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <param name="nodeType">The type of node to stop at.</param>
        /// <param name="nodeName">The name of the node to stop at.</param>
        private static void ReadToNextNodeType(XmlReader reader, XmlNodeType nodeType, String nodeName)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }
            if (nodeName == null)
            {
                throw new ArgumentNullException("nodeName");
            }
            
            ReadToNextNodeType(reader, nodeType);
            while (reader.Name != nodeName)
            {
                ReadToNextNodeType(reader, nodeType);
            }
        }

        #endregion

        #region Read ITestResult Methods

        /// <summary>
        /// Reads a TestSuite from the supplied reader.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <param name="parentNamespace">The test suite's parent namespace.</param>
        /// <returns>A TestSuite object.</returns>
        private ITestResult ReadTestSuite(XmlReader reader, String parentNamespace)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            var name = ReadName(reader);
            var status = ReadStatus(reader);
            var time = ReadTime(reader);
            var asserts = ReadAsserts(reader);
            var type = ReadType(reader);

            String nSpace = null;

            switch (type)
            {
                case SuiteType.Namespace:
                case SuiteType.TestFixture:
                    if (String.IsNullOrEmpty(parentNamespace))
                    {
                        nSpace = name;
                    }
                    else
                    {
                        nSpace = String.Format("{0}.{1}", parentNamespace, name);
                    }
                    break;
                default:
                    nSpace = parentNamespace;
                    break;
            }

            reader.MoveToElement();
            
            using (var subtree = reader.ReadSubtree())
            {
                subtree.Read();
                return new TestSuite(name, parentNamespace ?? String.Empty, status, type, time, asserts, ReadResults(subtree, nSpace));
            }
        }

        /// <summary>
        /// Reads a TestCase from the supplied reader.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <param name="parentNamespace">The test case's parent namespace.</param>
        /// <returns>A TestCase object.</returns>
        private ITestResult ReadTestCase(XmlReader reader, String parentNamespace)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            var name = ReadName(reader);
            var status = ReadStatus(reader);
            var time = ReadTime(reader);
            var asserts = ReadAsserts(reader);

            if (parentNamespace != null && name.StartsWith(parentNamespace + "."))
            {
                name = name.Remove(0, parentNamespace.Length + 1);
            }

            switch (status)
            {
                case Status.Error:
                case Status.Failed:
                    ReadToNextNodeType(reader, XmlNodeType.Element, "message");
                    var message = reader.ReadString();
                    ReadToNextNodeType(reader, XmlNodeType.Element, "stack-trace");
                    var stackTrace = reader.ReadString();
                    return new FailedTestCase(name, parentNamespace ?? String.Empty, status, time, asserts, message, stackTrace);
                case Status.Ignored:
                case Status.Inconclusive:
                case Status.NonRunnable:
                    ReadToNextNodeType(reader, XmlNodeType.Element, "message");
                    var ignoreMessage = reader.ReadString();
                    return new NotRunTestCase(name, parentNamespace ?? String.Empty, status, time, asserts, ignoreMessage);
                default:
                    return new TestCase(name, parentNamespace ?? String.Empty, status, time, asserts);
            }
        }

        #endregion

        #region Read Attribute Methods

        /// <summary>
        /// Reads the type attribute from the specified reader's current element.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <returns>The value of the type element, or SuiteType.Unrecognised if there was a problem.</returns>
        private SuiteType ReadType(XmlReader reader)
        {
            String typeString;

            if (!GetAttributeValue(reader, "type", out typeString))
            {
                warnings.Add(new Warning("Element with no type attribute", streamProvider.Name));
                return SuiteType.Unrecognised;
            }
            
            SuiteType type;

            if (!TryParseType(typeString, out type))
            {
                warnings.Add(new Warning(String.Format("Unrecognised type attribute: '{0}'", typeString), streamProvider.Name));
                return SuiteType.Unrecognised;
            }

            return type;
        }

        /// <summary>
        /// Reads the name attribute from the specified reader's current element.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <returns>The value of the name attribute, or [No Name] if no name attribute was present.</returns>
        private String ReadName(XmlReader reader)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            String name;

            if (!GetAttributeValue(reader, "name", out name))
            {
                warnings.Add(new Warning("Element with no name attribute", streamProvider.Name));
                return "[No Name]";
            }

            return name;
        }

        /// <summary>
        /// Reads the result attribute from the specified reader's current element.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <returns>The value of the result attribute, or Status.Unrecognised if there was a problem.</returns>
        private Status ReadStatus(XmlReader reader)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            String statusString;

            if (!GetAttributeValue(reader, "result", out statusString))
            {
                warnings.Add(new Warning("Element with no result attribute", streamProvider.Name));
                return Status.Unrecognised;
            }
            
            Status status;

            if (!TryParseStatus(statusString, out status))
            {
                warnings.Add(new Warning(String.Format("Unrecognised result attribute : '{0}'", statusString), streamProvider.Name));
                return Status.Unrecognised;
            }

            return status;
        }

        /// <summary>
        /// Reads the time attribute from the specified reader's current element.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <returns>The value of the time attribute if present.</returns>
        private Nullable<TimeSpan> ReadTime(XmlReader reader)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            String timeString;

            if (!GetAttributeValue(reader, "time", out timeString))
            {
                return null;
            }
            
            Double timeDouble;

            if (!Double.TryParse(timeString, out timeDouble))
            {
                warnings.Add(new Warning(String.Format("Time attribute contained a time which could not be parsed to a double: '{0}'", timeString), streamProvider.Name));
                return null;
            }

            return TimeSpan.FromSeconds(timeDouble);
        }

        /// <summary>
        /// Reads the asserts attribute from the specified reader's current element.
        /// </summary>
        /// <param name="reader">The reader to read from.</param>
        /// <returns>The value if the asserts attribute if present.</returns>
        private Nullable<Int32> ReadAsserts(XmlReader reader)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }

            String assertsString;

            if (!GetAttributeValue(reader, "asserts", out assertsString))
            {
                return null;
            }

            Int32 assertsInt32;

            if (!Int32.TryParse(assertsString, out assertsInt32))
            {
                warnings.Add(new Warning(String.Format("Asserts attribute contained a value which could not be parsed to an int: '{0}'", assertsString), streamProvider.Name));
            }

            return assertsInt32;
        }

        /// <summary>
        /// Tries to get the value of the specified attribute.
        /// </summary>
        /// <param name="reader">The XML reader to read from.</param>
        /// <param name="attributeName">The name of the attribute to get the value for.</param>
        /// <param name="attributeValue">The value of the attribute if it was found.</param>
        /// <returns>True if the value was found.</returns>
        private static Boolean GetAttributeValue(XmlReader reader, String attributeName, out String attributeValue)
        {
            if (reader == null)
            {
                throw new ArgumentNullException("reader");
            }
            if (attributeName == null)
            {
                throw new ArgumentNullException("attributeName");
            }

            reader.MoveToAttribute(attributeName);

            if (!reader.Name.Equals(attributeName))
            {
                attributeValue = null;
                return false;
            }

            attributeValue = reader.Value;
            return true;
        }

        #endregion

        #region Parse Methods

        /// <summary>
        /// Tries to convert a string to a status.
        /// </summary>
        /// <param name="statusString">The string to convert.</param>
        /// <param name="status">The status the string represents if the conversion was successful.</param>
        /// <returns>True if the conversion was successful, false otherwise.</returns>
        private static Boolean TryParseStatus(String statusString, out Status status)
        {
            var statusses = new Dictionary<String, Status>
            {
                { "Error", Status.Error },
                { "Failure", Status.Failed },
                { "Ignored", Status.Ignored },
                { "Inconclusive", Status.Inconclusive },
                { "NotRunnable", Status.NonRunnable },
                { "Success", Status.Success }
            };

            return statusses.TryGetValue(statusString, out status);
        }

        /// <summary>
        /// Tries to convert a string to a SuiteType.
        /// </summary>
        /// <param name="typeString">The string to convert.</param>
        /// <param name="type">The SuiteType the string represents if the conversion was successful.</param>
        /// <returns>True if the conversion was successful, false otherwise.</returns>
        private static Boolean TryParseType(String typeString, out SuiteType type)
        {
            var suiteTypes = new Dictionary<String, SuiteType>
            {
                { "Assembly", SuiteType.Assembly },
                { "Namespace", SuiteType.Namespace },
                { "ParameterizedTest", SuiteType.ParameterizedTest },
                { "Test Project", SuiteType.TestProject },
                { "TestFixture", SuiteType.TestFixture },
                { "Theory", SuiteType.Theory }
            };

            return suiteTypes.TryGetValue(typeString, out type);
        }

        #endregion
    }
}
